PostgreSQL 窗口函数

1 本章背景知识

PostgreSQL 数据库定义了许多专门用于数据分析的窗口函数(Window Function)。

窗口函数是针对每一行数据,基于和它相关的一组数据计算出一个结果。下图演示了聚合函数和窗口函数的效果:

COUNT
COUNT
SUM
SUM
AVG
AVG
聚合函数
聚合函数
COUNT
COUNT
SUM
SUM
AVG
AVG
窗口函数
窗口函数
Text is not SVG - cannot display

2 环境准备

导入示例库PostgreSQL HR 示例库
导入示例库 [[sales_montyly.sql]]。
导入示例库 [[sales_data.sql]]。

3 聚合函数和窗口函数

下示例分别将 AVG、COUNT、SUM 作为聚合函数和窗口函数,计算员工的平均月薪、人数总和以及月薪总和。

3.1 聚合函数

SELECT AVG(salary), COUNT(*), SUM(salary)
FROM employees;
//屏幕输出:
     avg             |count|   sum   |
---------------------|-----|---------|
6461.8317757009345794|  107|691416.00|

3.2 窗口函数

SELECT employee_id,first_name, last_name, AVG(salary) OVER (), COUNT(*) OVER (), SUM(salary) OVER ()
FROM employees;
//屏幕输出:
employee_id|first_name |last_name  |avg        |count|sum      |
-----------|-----------|-----------|---------------------|-----|---------|
        100|Steven     |King       |6461.8317757009345794|  107|691416.00|
        101|Neena      |Kochhar    |6461.8317757009345794|  107|691416.00|
        102|Lex        |De Haan    |6461.8317757009345794|  107|691416.00|
... ...

聚合函数通常也可以作为窗口函数,区别在于后者包含了 OVER 关键字;空括号表示将所有数据作为整体进行分析,所以得到的数值和聚合函数一样。第二个区别,窗口函数为每一个员工都返回了一个结果。

4 窗口函数的分类

常见的窗口函数可以分为以下几类
1、聚合窗口函数。
2、排名窗函数。
3、取值窗口函数。
PostgreSQL 还提供了更多复杂的窗口选项,可以参考官方文档

5 窗口函数的定义

窗口函数的定义如下:

window_function ( expression, ... ) OVER (
    PARTITION BY ...
    ORDER BY ...
    frame_clause
)

1、window_function 是窗口函数的名称;
2、expression 是参数,有些函数不需要参数;
3、 OVER 子句包含三个选项:
(1)分区(PARTITION BY)。
(2)排序(ORDER BY)。
(3)窗口大小(frame_clause)。
下面将会对这三个选项进行详细阐述。

5.1 分区选项(PARTITION BY)

PARTITION BY 选项用于定义分区,作用类似于 GROUP BY 的分组。

1、如果指定了分区选项,窗口函数将会分别针对每个分区单独进行分析;
2、如果省略分区选项,所有的数据作为一个整体进行分析。

以下语句按照部门进行分组,分析每个部门的平均月薪:

SELECT first_name, last_name, department_id, salary, 
AVG(salary) OVER (PARTITION BY department_id)
  FROM employees
  ORDER BY department_id;
  //屏幕输出:
 first_name  |  last_name  | department_id |  salary  | AVG
-------------+-------------+---------------+----------+-----
| Jennifer    | Whalen      |  10 |  4400.00 |4400.0000000000000000
 Pat         | Fay         |  20 |  6000.00 |9500.0000000000000000
 Michael     | Hartstein   |  20 | 13000.00 |9500.0000000000000000
 Shelli      | Baida       |  30 |  2900.00 |4150.0000000000000000
 Karen       | Colmenares  |  30 |  2500.00 |4150.0000000000000000
 Den         | Raphaely    |  30 | 11000.00 |4150.0000000000000000
 Alexander   | Khoo        |  30 |  3100.00 |4150.0000000000000000
 Sigal       | Tobias      |  30 |  2800.00 |4150.0000000000000000
 Guy         | Himuro      |  30 |  2600.00 |4150.0000000000000000
 Susan       | Mavris      |  40 |  6500.00 |6500.0000000000000000
 Steven      | Markle      |  50 |  2200.00 |3475.5555555555555556
 Laura       | Bissot      |  50 |  3300.00 |3475.5555555555555556
 Mozhe       | Atkinson    |  50 |  2800.00 |3475.5555555555555556
 James       | Marlow      |  50 |  2500.00 |3475.5555555555555556

... ...

部门 10 只有一个员工,平均月薪就是她自己的月薪 4400;部门 20 有两个员工,平均月薪等于 (6000 + 13000)/2 = 9500;其他数据依次类推。

5.2 排序选项(ORDER BY)

ORDER BY 选项用于指定分区内的排序方式,通常用于数据的排名分析。

以下语句用于计算每个员工在部门内的入职顺序:

SELECT first_name, last_name, department_id, hire_date,
       RANK() OVER (PARTITION BY department_id ORDER BY hire_date)
  FROM employees
  ORDER BY department_id;
  //屏幕输出:
 first_name  |  last_name  | department_id | hire_date | RANK
-------------+-------------+---------------+---------------------+------
 Jennifer    | Whalen      |  10 | 1987-09-17 00:00:00 |    1
 Michael     | Hartstein   |  20 | 1996-02-17 00:00:00 |    1
 Pat         | Fay         |  20 | 1997-08-17 00:00:00 |    2
 Den         | Raphaely    |  30 | 1994-12-07 00:00:00 |    1
 Alexander   | Khoo        |  30 | 1995-05-18 00:00:00 |    2
 Sigal       | Tobias      |  30 | 1997-07-24 00:00:00 |    3
 Shelli      | Baida       |  30 | 1997-12-24 00:00:00 |    4
 Guy         | Himuro      |  30 | 1998-11-15 00:00:00 |    5
 Karen       | Colmenares  |  30 | 1999-08-10 00:00:00 |    6

... ...
  1. PARTITION BY 选项表示按照部门进行分区;
  2. ORDER BY 选项指定在部门内按照入职先后进行排序;
  3. RANK 函数用于计算名次,在 排名窗口函数 将会进行介绍。
  4. 部门 10 只有一个员工,Jennifer 就是第一个入职的员工;
  5. 部门 20 有两个员工,Michael(1996-02-17)比 Pat(1997-08-17)入职更早;
Note

ORDER BY 子句同样支持 NULLS FIRST 和 NULLS LAST 选项,用于指定空值的排序顺序。默认为 NULLS LAST。

5.3 窗口选项(frame_clause)

frame_clause 选项用于在当前分区内指定一个计算窗口。

指定了窗口之后,分析函数不再基于分区进行计算,而是基于窗口内的数据进行计算。

5.3.1 窗口选项语法

{ ROWS | RANGE } frame_start
{ ROWS | RANGE } BETWEEN frame_start AND frame_end

1、 ROWS 表示在分区中计算窗口的偏移量。
2、 RANGE 表示以数值(例如 30 分钟)为单位计算窗口的偏移量。
3、其中,frame_start 用于定义窗口的起始位置,可以指定以下内容之一。

(1)UNBOUNDED PRECEDING,窗口从第一行开始,此参数为默认值;
(2) N PRECEDING,窗口从当前行之前的第 N 行或者数值开始;
(3)CURRENT ROW,窗口从当前行开始。

4、frame_end 用于定义窗口的结束位置,可以指定以下内容之一:

(1)CURRENT ROW,窗口到当前行结束,默认值;
(2)N FOLLOWING,窗口到当前行之后的第 N 行或者数值结束;
(3)UNBOUNDED FOLLOWING,窗口到分区的最后一行结束。

下图可以方便我们理解这些选项的含义:

当前分区
当前分区
UNBOUNDED PRECEDING
UNBOUNDED PRECEDING
N PRECEDING
N PRECEDING
CURRENT ROW
CURRENT ROW
N FOLLOWING
N FOLLOWING
UNBOUNDED FOLLOWING
UNBOUNDED FOLLOWING
Text is not SVG - cannot display

5.3.2 窗口选项使用的注意事项

1、CURRENT ROW 表示当前正在处理的行;
2、其他的行可以使用相对当前行的位置表示。
3、常用的聚合函数,例如 AVG、SUM、COUNT 等,也可以作为窗口函数使用。

Warning

需要注意,窗口的大小不会超出当前分区的范围。

5.4 窗口函数的运行步骤

1、运行 GROUP BY 分组、聚合函数以及 HAVING 过滤。
2、运行多个窗口函数。
3、如果多个窗口函数拥有相同的 PARTITION BYORDER BY 选项,它们会在遍历数据时一起进行读取输入数据。

6 聚合窗口函数

6.1 计算每个产品当前月份的累计销量

SELECT product AS "产品", ym "年月", amount "销量",
       SUM(amount) OVER (PARTITION BY product ORDER BY ym ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
  FROM sales_monthly
 ORDER BY product, ym;
 //屏幕输出:
 产品 |  年月  |   销量   |    SUM
------+--------+----------+-----------
 桔子 | 201801 | 10154.00 |  10154.00
 桔子 | 201802 | 10183.00 |  20337.00
 桔子 | 201803 | 10245.00 |  30582.00
 桔子 | 201804 | 10325.00 |  40907.00
 桔子 | 201805 | 10465.00 |  51372.00
 桔子 | 201806 | 10505.00 |  61877.00
......

1、PARTITION BY 选项表示按照产品进行分区;
2、ORDER BY 选项表示按照日期进行排序;
3、窗口子句 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 指定窗口从当前分区的第一行开始到当前行结束;
4、SUM 函数计算的是产品累计到当前月份为止的销量合计。

6.2 使用 AVG 函数计算移动平均值

SELECT saledate, amount, avg(amount) OVER (ORDER BY saledate ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)
  FROM sales_data
 WHERE product = '桔子' AND channel = '淘宝';
 //屏幕输出:
      saledate       | amount  |          avg
---------------------+---------+-----------------------
 2019-01-01 00:00:00 | 1864.00 | 1893.5000000000000000
 2019-01-02 00:00:00 | 1923.00 | 1505.3333333333333333
 2019-01-03 00:00:00 |  729.00 | 1066.3333333333333333
 2019-01-04 00:00:00 |  547.00 |  966.6666666666666667
 2019-01-05 00:00:00 | 1624.00 | 1272.0000000000000000
 2019-01-06 00:00:00 | 1645.00 | 1332.0000000000000000
 2019-01-07 00:00:00 |  727.00 | 1394.3333333333333333
 2019-01-08 00:00:00 | 1811.00 | 1182.3333333333333333
 2019-01-09 00:00:00 | 1009.00 | 1218.6666666666666667
 2019-01-10 00:00:00 |  836.00 | 1172.0000000000000000

... ... 

该语句返回了“桔子”在“淘宝”上的销量,以及每一天和它前后一天(共 3 天)的平均销量。

6.3 移动平均值通常用于处理时间序列的数据

例如,厂房的温度检测器获取了每秒钟的温度,我们可以使用以下窗口计算前五分钟内的平均温度。

AVG(temperature) OVER (ORDER BY ts RANGE BETWEEN interval '5 minute' PRECEDING AND CURRENT ROW)

7 排名窗口函数

7.1 常见的排名窗口函数

排名窗口函数用于对数据进行分组排名。常见的排名窗口函数包括:

函数 说明
ROW_NUMBER 为分区中的每行数据分配一个序列号,序列号从 1 开始分配。
RANK 计算每行数据在其分区中的名次;如果存在名次相同的数据,后续的排名将会产生跳跃。
DENSE_RANK 计算每行数据在其分区中的名次;即使存在名次相同的数据,后续的排名也是连续的值。
PERCENT_RANK 以百分比的形式显示每行数据在其分区中的名次;如果存在名次相同的数据,后续的排名将会产生跳跃。
CUME_DIST 计算每行数据在其分区内的累积分布,也就是该行数据及其之前的数据的比率;取值范围大于 0 并且小于等于 1。
NTILE 将分区内的数据分为 N 等份,为每行数据计算其所在的位置。
Warning

排名窗口函数不支持动态的窗口大小(frame_clause),而是以当前分区作为分析的窗口。

7.2 按照部门为单位,计算员工的月薪排名

SELECT d.department_name "部门名称", concat(e.first_name, ',' , e.last_name) "姓名", e.salary "月薪",
       ROW_NUMBER() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "row_number",
       RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "rank",
       DENSE_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "dense_rank",
       PERCENT_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "percent_rank"
  FROM employees e
  JOIN departments d ON (e.department_id = d.department_id)
 WHERE d.department_name in ('IT', 'Purchasing')
 ORDER BY 1, 4;
 //屏幕输出:
  部门名称  |       姓名       |   月薪   | row_number | rank | dense_rank | percent_rank
------------+------------------+----------+------------+------+------------+--------------
 IT         | Alexander,Hunold |  9000.00 |          1 |    1 |          1 |            0
 IT         | Bruce,Ernst      |  6000.00 |          2 |    2 |          2 |         0.25
 IT         | Valli,Pataballa  |  4800.00 |          3 |    3 |          3 |          0.5
 IT         | David,Austin     |  4800.00 |          4 |    3 |          3 |          0.5
 IT         | Diana,Lorentz    |  4200.00 |          5 |    5 |          4 |            1
 Purchasing | Den,Raphaely     | 11000.00 |          1 |    1 |          1 |            0
 Purchasing | Alexander,Khoo   |  3100.00 |          2 |    2 |          2 |          0.2
 Purchasing | Shelli,Baida     |  2900.00 |          3 |    3 |          3 |          0.4
 Purchasing | Sigal,Tobias     |  2800.00 |          4 |    4 |          4 |          0.6
 Purchasing | Guy,Himuro       |  2600.00 |          5 |    5 |          5 |          0.8
 Purchasing | Karen,Colmenares |  2500.00 |          6 |    6 |          6 |            1

1、ROW_NUMBER 函数为每个员工分配了一个连续的数字编号,可以看作是一种排名。IT 部门的“Valli,Pataballa”和“David,Austin”的月薪相同,但是rownumber不同;

2、RANK 函数为每个员工指定了一个名次。IT 部门的“Valli,Pataballa”和“David,Austin”的名次都是 3;而且在他们之后的“Diana,Lorentz”的名次为 5,产生了跳跃;

3、DENSE_RANK 函数为每个员工指定了一个名次,IT 部门的“Valli,Pataballa”和“David,Austin”的名次都是 3;在他们之后的“Diana,Lorentz”的名次为 4,名次是连续值;

4、PERCENT_RANK 函数按照百分比指定名次,取值位于 0 到 1 之间。其中“Diana,Lorentz”的百分比排名为 1,也产生了跳跃。

7.3 当窗口范围一样时,则可以简化写法

以上示例中 4 个窗口函数的 OVER 子句完全相同,此时可以采用一种更简单的写法:

SELECT d.department_name "部门名称", concat(e.first_name, ',' , e.last_name) "姓名", e.salary "月薪",
       ROW_NUMBER() OVER w AS "row_number",
       RANK() OVER w AS "rank",
       DENSE_RANK() OVER w AS "dense_rank",
       PERCENT_RANK() OVER w AS "percent_rank"
  FROM employees e
  JOIN departments d ON (e.department_id = d.department_id)
 WHERE d.department_name in ('IT', 'Purchasing')
 WINDOW w AS (PARTITION BY e.department_id ORDER BY e.salary DESC)
 ORDER BY 1, 4;
 //屏幕输出:
 部门名称  |       姓名       |   月薪   | row_number | rank | dense_rank | percent_rank
------------+------------------+----------+------------+------+------------+--------------
 IT         | Alexander,Hunold |  9000.00 |          1 |    1 |          1 |            0
 IT         | Bruce,Ernst      |  6000.00 |          2 |    2 |          2 |         0.25
 IT         | Valli,Pataballa  |  4800.00 |          3 |    3 |          3 |          0.5
 IT         | David,Austin     |  4800.00 |          4 |    3 |          3 |          0.5
 IT         | Diana,Lorentz    |  4200.00 |          5 |    5 |          4 |            1
 Purchasing | Den,Raphaely     | 11000.00 |          1 |    1 |          1 |            0
 Purchasing | Alexander,Khoo   |  3100.00 |          2 |    2 |          2 |          0.2
 Purchasing | Shelli,Baida     |  2900.00 |          3 |    3 |          3 |          0.4
 Purchasing | Sigal,Tobias     |  2800.00 |          4 |    4 |          4 |          0.6
 Purchasing | Guy,Himuro       |  2600.00 |          5 |    5 |          5 |          0.8
 Purchasing | Karen,Colmenares |  2500.00 |          6 |    6 |          6 |            1

Note

其中,WINDOW 定义了一个窗口变量 w,然后在窗口函数的 OVER 子句中使用了该变量;这样可以简化函数的输入。

7.4 CUME_DIST 和 NTILE 函数

SELECT concat(first_name, ',' , last_name) "姓名", hire_date AS "入职日期",
       CUME_DIST() OVER (ORDER BY hire_date) AS "累积占比",
       NTILE(100) OVER (ORDER BY hire_date) AS "相对位置"
FROM employees;
  //屏幕输出:
       姓名        |      入职日期       |       累积占比       | 相对位置
-------------------+---------------------+----------------------+----------
 Steven,King       | 1987-06-17 00:00:00 | 0.009345794392523364 |        1
 Jennifer,Whalen   | 1987-09-17 00:00:00 | 0.018691588785046728 |        1
 Neena,Kochhar     | 1989-09-21 00:00:00 | 0.028037383177570093 |        2
 Alexander,Hunold  | 1990-01-03 00:00:00 | 0.037383177570093455 |        2
 Bruce,Ernst       | 1991-05-21 00:00:00 |  0.04672897196261682 |        3
 Lex,De Haan       | 1993-01-13 00:00:00 | 0.056074766355140186 |        3
 Shelley,Higgins   | 1994-06-07 00:00:00 |  0.09345794392523364 |        4
 William,Gietz     | 1994-06-07 00:00:00 |  0.09345794392523364 |        4
 Susan,Mavris      | 1994-06-07 00:00:00 |  0.09345794392523364 |        5
 Hermann,Baer      | 1994-06-07 00:00:00 |  0.09345794392523364 |        5
 Daniel,Faviet     | 1994-08-16 00:00:00 |    0.102803738317757 |        6
 Nancy,Greenberg   | 1994-08-17 00:00:00 |  0.11214953271028037 |        6
 Den,Raphaely      | 1994-12-07 00:00:00 |  0.12149532710280374 |        7
 Payam,Kaufling    | 1995-05-01 00:00:00 |   0.1308411214953271 |        7
 Alexander,Khoo    | 1995-05-18 00:00:00 |  0.14018691588785046 |        8

... ... 

CUME_DIST 函数显示 1987-09-17 以及之前入职的员工大概有 1.8%(2/107);NTILE(100) 函数表明前 2% 入职的员工有“Steven,King”和“Jennifer,Whalen”。

Warning

由于员工总数为 107,所以对于统计并不是完全准确。

8 取值窗口函数

8.1 常见的取值窗口函数

取值窗口函数用于返回指定位置上的数据。常见的取值窗口函数包括:

函数名 说明
FIRST_VALUE 返回窗口内第一行的数据。
LAST_VALUE 返回窗口内最后一行的数据。
NTH_VALUE 返回窗口内第 N 行的数据。
LAG 返回分区中当前行之前的第 N 行的数据。
LEAD 返回分区中当前行之后第 N 行的数据。

LAG 和 LEAD 函数不支持动态的窗口大小(frame_clause),是以当前分区作为分析的窗口。

8.2 获取每个部门内部月薪最高、月薪最低以及月薪第三高的员工

以下语句使用 FIRST_VALUELAST_VALUE 以及 NTH_VALUE 函数分别获取每个部门内部月薪最高、月薪最低以及月薪第三高的员工:

SELECT department_id, first_name, last_name, salary,
       FIRST_VALUE(salary) OVER w,
       LAST_VALUE(salary) OVER w,
       NTH_VALUE(salary, 3) OVER w
  FROM employees
WINDOW w AS (PARTITION BY department_id ORDER BY salary desc ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
 ORDER BY department_id, salary DESC;
 //屏幕输出:
department_id | first_name  |  last_name  |  salary  | FIRST_VALUE | LAST_VALUE | NTH_VALUE
---------------+-------------+-------------+----------+-------------+------------+-----------
            10 | Jennifer    | Whalen      |  4400.00 |     4400.00 |    4400.00 |
            20 | Michael     | Hartstein   | 13000.00 |    13000.00 |    6000.00 |
            20 | Pat         | Fay         |  6000.00 |    13000.00 |    6000.00 |
            30 | Den         | Raphaely    | 11000.00 |    11000.00 |    2500.00 |   2900.00
            30 | Alexander   | Khoo        |  3100.00 |    11000.00 |    2500.00 |   2900.00
            30 | Shelli      | Baida       |  2900.00 |    11000.00 |    2500.00 |   2900.00
            30 | Sigal       | Tobias      |  2800.00 |    11000.00 |    2500.00 |   2900.00
            30 | Guy         | Himuro      |  2600.00 |    11000.00 |    2500.00 |   2900.00
            30 | Karen       | Colmenares  |  2500.00 |    11000.00 |    2500.00 |   2900.00

... ... ...

1、以上三个函数的默认窗口是从当前分区的第一行到当前行,所以我们在 OVER 子句中将窗口设置为整个分区。

8.3 统计不同产品每个月的环比增长率

LAG 和 LEAD 函数同样用于计算销量数据的环比/同比增长。
例如,以下语句统计不同产品每个月的环比增长率:

WITH sales_monthly AS (
  SELECT product, to_char(saledate,'YYYYMM') ym, sum(amount) sum_amount
    FROM sales_data
   GROUP BY product, to_char(saledate,'YYYYMM')
)
SELECT product AS "产品", ym "年月", sum_amount "销量",
       (sum_amount - LAG(sum_amount, 1) OVER (PARTITION BY product ORDER BY ym))/
       LAG(sum_amount, 1) OVER (PARTITION BY product ORDER BY ym) * 100 AS "环比增长率(%)"
  FROM sales_monthly
 ORDER BY product, ym;
 //屏幕输出:

 产品 |  年月  |   销量    |     环比增长率(%)
------+--------+-----------+-------------------------
 桔子 | 201901 | 126083.00 |
 桔子 | 201902 | 119417.00 | -5.28699348841636065100
 桔子 | 201903 | 147290.00 | 23.34089786211343443600
 桔子 | 201904 | 147848.00 |  0.37884445651435942700
 桔子 | 201905 | 182417.00 | 23.38144580920945836300
 桔子 | 201906 | 186206.00 |  2.07710904137224052600
 苹果 | 201901 | 125083.00 |
 苹果 | 201902 | 124421.00 | -0.52924857894358146200
 苹果 | 201903 | 150230.00 | 20.74328288632947814300
 苹果 | 201904 | 170360.00 | 13.39945417027224921800
 苹果 | 201905 | 180136.00 |  5.73843625264146513300
 苹果 | 201906 | 186822.00 |  3.71164009415108584600
 香蕉 | 201901 | 120557.00 |
 香蕉 | 201902 | 118356.00 | -1.82569241105866934300
 香蕉 | 201903 | 155412.00 | 31.30893237351718544100
 香蕉 | 201904 | 161857.00 |  4.14704141250353898000
 香蕉 | 201905 | 181689.00 | 12.25279104394619942300
 香蕉 | 201906 | 187498.00 |  3.19722162596524830900

... ... ... ...

1、创建一个通用表表达式 sales_monthly,得到了不同产品每个月的销量汇总;
2、LAG(sum_amount, 1) 表示获取上一期的销量;
3、当前月份的销量减去上个月的销量,再除以上个月的销量,就是环比增长率。

Note